Amazon Chime SDK + Lambda + API GatewayでサーバーレスWeb会議アプリを作ってみた
こんにちは、CX事業本部の若槻です。
前回の記事ではAmazon Chime SDKを使ってローカル上でWeb会議アプリを作成しました。
今回は、Amazon Chime SDK、LambdaおよびAPI Gatewayを使って、Web会議アプリをAWS上にサーバーレス構成で作成してみました。
アウトプット
次のような複数人でビデオ会議ができるサーバーレスアプリを作ります。
ソースコードはGitHubに公開してあります。
やってみた
環境
% sw_vers ProductName: Mac OS X ProductVersion: 10.15.7 BuildVersion: 19H2 % node -v v12.14.0 % npm -v 6.13.4 % sam --version SAM CLI, version 1.6.2
アプリディレクトリ作成
% mkdir serverless-meeting-app % cd serverless-meeting-app
npm install
% npm init -y % npm install aws-sdk serverless-aws-static-file-handler uuid
serverless-aws-static-file-handler
はServerless Frameworkが提供する、WebアプリのフロントエンドをAWS Lambdaでホストできるようにするプラグインです。
通常であればサーバーレス構成のWebアプリの静的ファイルのホストはS3バケットなどのストレージを使用しますが、今回はフロントエンドを最低限の構成にしており静的ファイルの容量が小さいので、serverless-aws-static-file-handler
を活用して構成を単純化しています。
バックエンドの作成
handlers.js
作成
WebアプリのバックエンドとなるLambdaハンドラーのコードです。
% touch handlers.js
const AWS = require("aws-sdk"); const StaticFileHandler = require('serverless-aws-static-file-handler') const chime = new AWS.Chime(); const { v4: uuidv4 } = require("uuid"); chime.endpoint = new AWS.Endpoint("https://service.chime.aws.amazon.com"); const json = (statusCode, contentType, body) => { return { statusCode, headers: { "content-type": contentType }, body: JSON.stringify(body), }; }; exports.index = async (event, context) => { const clientFilesPath = __dirname + "/html/"; const fileHandler = new StaticFileHandler(clientFilesPath) return await fileHandler.get(event,context); } exports.join = async (event) => { const query = event.queryStringParameters; let meetingId = null; let meeting = null; if (!query.meetingId) { meetingId = uuidv4(); meeting = await chime .createMeeting({ ClientRequestToken: meetingId, MediaRegion: "eu-west-1", ExternalMeetingId: meetingId, }) .promise(); } else { meetingId = query.meetingId; meeting = await chime .getMeeting({ MeetingId: meetingId, }) .promise(); } const attendee = await chime .createAttendee({ MeetingId: meeting.Meeting.MeetingId, ExternalUserId: `${uuidv4().substring(0, 8)}#${query.clientId}`, }) .promise(); return json(200, "application/json", { Info: { Meeting: meeting, Attendee: attendee, }, }); };
index
では、serverless-aws-static-file-handler
により静的ファイルをホストするLambdaハンドラーを定義しています。
exports.index = async (event, context) => { const clientFilesPath = __dirname + "/html/"; const fileHandler = new StaticFileHandler(clientFilesPath) return await fileHandler.get(event,context); }
join
では、ユーザーが会議に参加する際に必要となる会議IDおよび参加者IDを払い出すLambdaハンドラーを定義しています。
exports.join = async (event) => { const query = event.queryStringParameters; let meetingId = null; let meeting = null; if (!query.meetingId) { meetingId = uuidv4(); meeting = await chime .createMeeting({ ClientRequestToken: meetingId, MediaRegion: "eu-west-1", ExternalMeetingId: meetingId, }) .promise(); } else { meetingId = query.meetingId; meeting = await chime .getMeeting({ MeetingId: meetingId, }) .promise(); } const attendee = await chime .createAttendee({ MeetingId: meeting.Meeting.MeetingId, ExternalUserId: `${uuidv4().substring(0, 8)}#${query.clientId}`, }) .promise(); return json(200, "application/json", { Info: { Meeting: meeting, Attendee: attendee, }, }); };
フロントエンドの作成
フロントエンド用のディレクトリを作成します。
% mkdir -p html/assets/js
app.js
作成
app.js
ではでWebアプリ画面上のアクションを定義します。
% touch html/assets/js/app.js
var startButton = document.getElementById("start-button"); var urlParams = new URLSearchParams(window.location.search); function generateString() { return ( Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15) ); } var isMeetingHost = false; var meetingId = urlParams.get("meetingId"); var clientId = generateString(); const logger = new ChimeSDK.ConsoleLogger( "ChimeMeetingLogs", ChimeSDK.LogLevel.INFO ); const deviceController = new ChimeSDK.DefaultDeviceController(logger); let requestPath = `join?clientId=${clientId}`; if (!meetingId) { isMeetingHost = true; } else { requestPath += `&meetingId=${meetingId}`; } if (!isMeetingHost) { startButton.innerText = "Join!"; } else { startButton.innerText = "Start!"; } startButton.style.display = "block"; async function start() { if (typeof meetingSession !== 'undefined' && meetingSession) { return } try { var response = await fetch(requestPath, { method: "POST", headers: new Headers(), }); const data = await response.json(); meetingId = data.Info.Meeting.Meeting.MeetingId; if (isMeetingHost) { document.getElementById("meeting-link").innerText = window.location.href + "?meetingId=" + meetingId; } const configuration = new ChimeSDK.MeetingSessionConfiguration( data.Info.Meeting.Meeting, data.Info.Attendee.Attendee ); window.meetingSession = new ChimeSDK.DefaultMeetingSession( configuration, logger, deviceController ); const audioInputs = await meetingSession.audioVideo.listAudioInputDevices(); const videoInputs = await meetingSession.audioVideo.listVideoInputDevices(); await meetingSession.audioVideo.chooseAudioInputDevice( audioInputs[0].deviceId ); await meetingSession.audioVideo.chooseVideoInputDevice( videoInputs[0].deviceId ); const observer = { videoTileDidUpdate: (tileState) => { console.log("VIDEO TILE DID UPDATE"); console.log(tileState); if (!tileState.boundAttendeeId) { return; } updateTiles(meetingSession); }, }; meetingSession.audioVideo.addObserver(observer); meetingSession.audioVideo.startLocalVideoTile(); const audioOutputElement = document.getElementById("meeting-audio"); meetingSession.audioVideo.bindAudioElement(audioOutputElement); meetingSession.audioVideo.start(); } catch (err) { console.log(err) } } function updateTiles(meetingSession) { const tiles = meetingSession.audioVideo.getAllVideoTiles(); console.log("tiles", tiles); tiles.forEach(tile => { let tileId = tile.tileState.tileId var videoElement = document.getElementById("video-" + tileId); if (!videoElement) { videoElement = document.createElement("video"); videoElement.id = "video-" + tileId; document.getElementById("video-list").append(videoElement); meetingSession.audioVideo.bindVideoElement( tileId, videoElement ); } }) } window.addEventListener("DOMContentLoaded", () => { startButton.addEventListener("click", start); });
amazon-chime-sdk.min.js
作成
Amazon Chime SDK for JavaScriptを一つのJSファイルにバンドルしたamazon-chime-sdk.min.js
を作成して、フロントエンドでAmazon Chime SDKを使用できるようにします。
amazon-chime-sdk-js
ディレクトリ内で作成したamazon-chime-sdk.min.js
をhtml/assets/js/
に配置します。
% git clone https://github.com/aws/amazon-chime-sdk-js.git % npm --prefix amazon-chime-sdk-js/demos/singlejs install rollup % npm --prefix amazon-chime-sdk-js/demos/singlejs run bundle % cp amazon-chime-sdk-js/demos/singlejs/build/amazon-chime-sdk.min.js \ html/assets/js/amazon-chime-sdk.min.js
index.html
作成
会議参加やビデオ会議を行うWebページです。
% touch html/index.html
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <title>Serverless Meetings</title> <script src="assets/js/amazon-chime-sdk.min.js"></script> <script src="https://unpkg.com/uuid@latest/dist/umd/uuidv4.min.js"></script> <style> #video-list video { width: 600px; height: 400px; } </style> </head> <body> <h1>Welcome to Serverless Meetings!</h1> <audio style="display: none" id="meeting-audio"></audio> <button type="button" id="start-button" style="display: none;"></button> <p id="meeting-link"></p> <div id="video-list"> </div> <script src="assets/js/app.js"></script> </body> </html>
SAMテンプレート作成
AWSにデプロイするサーバーレスアプリのリソースを定義します。
% touch template.yml
AWSTemplateFormatVersion: '2010-09-09' Transform: 'AWS::Serverless-2016-10-31' Globals: Function: Runtime: nodejs12.x Timeout: 30 MemorySize: 128 Resources: ChimeMeetingsAccessPolicy: Type: AWS::IAM::Policy Properties: PolicyName: ChimeMeetingsAccess PolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Action: - 'chime:*' Resource: '*' Roles: - Ref: MeetingJoinLambdaRole MeetingIndexLambda: Type: 'AWS::Serverless::Function' Properties: Handler: handlers.index Events: Api1: Type: Api Properties: Path: /{proxy+} Method: GET MeetingJoinLambda: Type: AWS::Serverless::Function Properties: Handler: handlers.join Events: Api1: Type: Api Properties: Path: /join Method: POST Outputs: ApiURL: Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/index.html"
アプリのデプロイ
SAMでアプリをAWSへデプロイします。リージョンはus-east-1
などAmazon Chime SDKが利用可能なリージョンを選択してください。
% sam build % sam deploy --guided
sam deploy
が成功すると次のようにコマンド実行画面にURLhttps://xxxxxxxxxx.execute-api.us-east-1.amazonaws.com/Prod/index.html
が出力されます。WebアプリのアクセスURLとなるので控えます。
... CloudFormation outputs from deployed stack ------------------------------------------------------------------------------------------------------------------------------------------------ Outputs ------------------------------------------------------------------------------------------------------------------------------------------------ Key ApiURL Description - Value https://xxxxxxxxxx.execute-api.us-east-1.amazonaws.com/Prod/index.html ------------------------------------------------------------------------------------------------------------------------------------------------ Successfully created/updated stack - sufle-meeting-app-with-chime-sdk in us-east-1
使ってみる
デプロイ時に出力したURLにアクセスします。[Start!]をクリックします。
すると会議IDmeetingId
が払い出されてタイルにカメラ映像が表示され会議に参加できます。またmeetingId
をクエリパラメータに含む会議URLが表示されます。
会議URLをほかの出席者に共有します。
ほかの出席者は共有された会議URLにアクセスして[Join!]をクリックします。
タイルが追加されてカメラ映像が表示され会議に参加できます。
終了時は、会議の終了や退出処理は今回は実装していないため、ブラウザタブを閉じて終了してください。
おわりに
Amazon Chime SDK、LambdaおよびAPI Gatewayを使って、Web会議アプリをAWS上にサーバーレス構成で作成してみました。
完全サーバーレスでWeb会議機能を持ったアプリを構築できるのは驚きですね。自社アプリへのWeb会議機能の組み込みもこれなら開発費用をとても抑えられそうです。
参考
- Serverless Meetings with Amazon Chime SDK | Sufle
- API Gatewayのデフォルトのゲートウェイのレスポンスを変更 - Qiita
- JavaScriptでよく見るエラーとその対策
- Cludfront + API Gateway + Lambda の環境でうまくいかない場合のレスポンスとその原因 - Qiita
- serverless-aws-static-file-handler - npm
- 変数が定義されているか、nullかどうかの判定をする[Javascript] - Qiita
- 【いまさらですが】Javascriptの「undefinded」と「is not define」の違い - Qiita
- Amazon Chime SDKをPHPとJavaScriptでサクッと動かす - Qiita
- 階層の深いディレクトリをmkdirする方法 - Qiita
- package.jsonのあるディレクトリパスを指定してnpm installを実行する - Motomichi Works Blog
以上